Skip to content

feat(app-router): filter skipped layouts from RSC responses and cached reads [3/6]#840

Closed
NathanDrake2406 wants to merge 3 commits intocloudflare:mainfrom
NathanDrake2406:feat/pr-768-3-skip-filter-server
Closed

feat(app-router): filter skipped layouts from RSC responses and cached reads [3/6]#840
NathanDrake2406 wants to merge 3 commits intocloudflare:mainfrom
NathanDrake2406:feat/pr-768-3-skip-filter-server

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

Summary

PR 3 of 6 — restack of #768. Stacked on #839.

Introduces `app-page-skip-filter.ts` with the canonical-bytes guarantee: the render path always produces the full RSC payload and writes it to the cache; the egress branch applies a byte-level filter that omits layouts the client asked to skip, but only if the server independently classified them as static (`computeSkipDecision`).

Wires the filter into `renderAppPageLifecycle` and `buildAppPageCachedResponse` so both fresh renders and cache hits honor the skip header. Parses the incoming `X-Vinext-Router-Skip` header at the handler scope and threads the resulting set through render and ISR.

Dormant until canonical-stream is validated: gated behind `supportsFilteredRscStream: false` in the generated entry so this PR lands inert at runtime. Tests exercise the filter directly by injecting the skip set into `renderAppPageLifecycle` options.

Canonical-bytes invariant

  1. `rscStream` is rendered from the canonical element (never mutated).
  2. `teeAppPageRscStreamForCapture` splits into `capturedRscDataPromise` (→ cache) and `responseStream` (→ client).
  3. The filter only applies to `responseStream` when `isRscRequest && supportsFilteredRscStream !== false && skipIds.size > 0`.
  4. The cache therefore always sees full bytes regardless of client skip state.

Stack

  1. [1/6] refactor(app-rsc-entry): centralize request-derived page inputs [1/6] #838 — centralize request-derived page inputs
  2. [2/6] feat(app-router): emit per-layout flags in the RSC payload [2/6] #839 — emit per-layout flags in the RSC payload
  3. [3/6] this PR — filter skipped layouts from RSC responses and cached reads
  4. [4/6] send X-Vinext-Router-Skip on navigation requests
  5. [5/6] wire build-time layout classification
  6. [6/6] classification reasons sidecar

Test plan

  • `tests/app-page-skip-filter.test.ts` (44 tests)
  • `tests/app-page-cache.test.ts` (17 tests)
  • `tests/app-page-render.test.ts` (21 tests, includes skip-filter section)
  • Verified tests pass WITHOUT client header wiring from PR 4

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 14, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@840

commit: 73d9224

…d reads

Introduce app-page-skip-filter.ts with the canonical-bytes guarantee:
the render path always produces the full RSC payload and writes it to
the cache; the egress branch applies a byte-level filter that omits
layouts the client asked to skip, but only if the server independently
classified them as static (computeSkipDecision).

Wire the filter into renderAppPageLifecycle and buildAppPageCachedResponse
so both fresh renders and cache hits honor the skip header. Parse the
incoming X-Vinext-Router-Skip header at the handler scope and thread
the resulting set through render and ISR.

Gate the filter behind supportsFilteredRscStream: false in the generated
entry so this PR is dormant at runtime until the canonical-stream story
is validated. Tests exercise the filter directly by injecting the skip
set into renderAppPageLifecycle options.
@NathanDrake2406 NathanDrake2406 force-pushed the feat/pr-768-3-skip-filter-server branch from 385440f to 4c2f81f Compare April 15, 2026 15:25
@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review April 15, 2026 15:32
Copilot AI review requested due to automatic review settings April 15, 2026 15:32
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: feat(app-router): filter skipped layouts from RSC responses and cached reads [3/6]

This is a well-structured PR. The canonical-bytes invariant is clearly maintained: the cache always sees full bytes, and filtering is only applied on the egress path. The defense-in-depth via computeSkipDecision (server must independently classify a layout as static before honoring a client skip request) is a solid security pattern. Gating behind supportsFilteredRscStream: false is a clean way to land inert code.

The test coverage is thorough — 44 filter tests, cache threading tests, render integration tests, and edge cases like cross-chunk row 0 parsing and malformed JSON fallback.

A few observations below.

// new live id through a deferred chunk.
for (const match of payload.matchAll(/(?<!\$)\$(?:[LBFQWK@])?([0-9a-fA-F]+)\b/g)) {
into.add(Number.parseInt(match[1], 16));
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regex-based reference extraction and the JSON-based collectRscReferenceIds overlap: references inside a JSON payload (like ["$L5","$L2"]) will be found by both the regex scan and the JSON.parsecollectRscReferenceIds path. This is harmless since into is a Set (idempotent adds), but the regex scan does redundant work for JSON-carrying rows.

Not a correctness issue — just noting the double-pass in case you later want to optimize the hot path. The regex path is necessary for non-JSON rows like 1:D"$3a", so the two can't simply be collapsed.

Also: the negative lookbehind (?<!\$) prevents matching $$5 (literal-dollar escape), but it won't prevent matching inside a JSON string value like "$$5" where the $ is a literal character. In practice this would only cause a false-positive live-id addition (keeping an extra row alive), which is safe — it errs on the side of over-inclusion rather than dropping rows.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left this behavior unchanged. The regex pass is still needed for non-JSON rows, and the JSON walk remains the simpler way to keep nested references honest. Since both paths feed a Set, the overlap stays idempotent; if this ever shows up in profiling, I’d optimize it as a separate hot-path pass rather than folding more heuristics into this PR.

// React Flight row-reference tags come from react-server-dom-webpack's client
// parser. Audit this character class when upgrading React so new reference-like
// tags do not become silent drop cases in the filter.
const REFERENCE_PATTERN = /^\$(?:[LBFQWK@])?([0-9a-fA-F]+)$/;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The REFERENCE_PATTERN character class [LBFQWK@] matches the current React 19.2.x tags. The comment rightly notes this needs auditing on React upgrades. Consider adding a test (or a comment linking to the specific React source location) that documents which React Flight tags are reference-like vs. escape-like, so the next person upgrading React knows exactly what to check.

For reference, the React source file is packages/react-client/src/ReactFlightClient.js — the resolveModel / tag switch statement.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a source breadcrumb next to REFERENCE_PATTERN pointing at React’s ReactFlightClient.js tag parser so the next React upgrade has an explicit audit target.

// App Router row 0 is always the plain JSON elements record. If that
// invariant changes, this filter must stop rewriting row 0 and fall back
// to canonical passthrough instead of serializing the wrong wire format.
const newRow0Raw = `0:${JSON.stringify(rewritten)}`;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: promoteToStreaming hardcodes 0: as the row 0 prefix. If React were to ever emit row 0 with a different hex representation (e.g., 00:), this would produce a different prefix than the original. Extremely unlikely given React's wire format, but since you already have the original row 0's raw prefix available, you could preserve it:

Suggested change
const newRow0Raw = `0:${JSON.stringify(rewritten)}`;
const newRow0Raw = `${row0Raw.slice(0, colonIndex + 1)}${JSON.stringify(rewritten)}`;

This is a nitpick — 0: is the correct canonical form.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. promoteToStreaming now preserves the original row-0 prefix from row0Raw instead of hardcoding 0:, and there’s a focused test covering a 00: row id form.

export const X_VINEXT_ROUTER_SKIP_HEADER = "X-Vinext-Router-Skip";
export const X_VINEXT_MOUNTED_SLOTS_HEADER = "X-Vinext-Mounted-Slots";

export function parseSkipHeader(header: string | null): ReadonlySet<string> {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The header parsing accepts any layout:* prefixed value without length or character validation. A malicious client could send a very long header with many entries to force the server to allocate a large Set. Since this runs on every RSC request (when the header is present), consider adding a cap — e.g., bail out or truncate after N entries (say 50, since deeply nested layout trees beyond that would be unusual).

This is low severity since Cloudflare Workers already enforce request header size limits, but defense-in-depth at the application layer doesn't hurt.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. parseSkipHeader now uses a bounded parser and stops after 50 unique layout:* ids, which keeps the allocation surface capped even if a client sends a pathological skip header. Added a test for the cap.

const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component");
const __skipLayoutIds = isRscRequest
? __parseSkipHeader(request.headers.get(__X_VINEXT_ROUTER_SKIP_HEADER))
: new Set();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When isRscRequest is false, this allocates a fresh new Set() on every non-RSC request. This is fine since it's a tiny allocation, but for consistency with the EMPTY_SKIP_SET pattern used in app-page-render.ts and app-page-cache.ts, you could use a shared sentinel. Not blocking — the generated entry intentionally avoids importing runtime helpers it doesn't need, and this allocation is negligible.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The generated RSC entry now uses a shared empty skip set for the non-RSC path instead of allocating a fresh new Set() per request. Updated the entry snapshots along with the template change.

@@ -106,7 +111,9 @@ export function buildAppPageCachedResponse(
rscHeaders["X-Vinext-Mounted-Slots"] = options.mountedSlotsHeader;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good use of EMPTY_SKIP_SET as the fallback — wrapRscBytesForResponse short-circuits on empty set and returns the raw ArrayBuffer without creating a ReadableStream. The cache-hit hot path stays allocation-free when no skip header is present.

const rscForResponse =
skipIds.size > 0
? rscCapture.responseStream.pipeThrough(createSkipFilterTransform(skipIds))
: rscCapture.responseStream;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth noting for future readers: rscForResponse is also used for the HTML SSR path (line 261 in this file), but skipIds will always be empty when !isRscRequest, so the HTML path always receives the unfiltered stream. The code flow is correct; a brief inline comment at the HTML usage site could save someone from a double-take.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added an inline comment at the HTML SSR call site clarifying that rscForResponse is still canonical there because skipIds only becomes non-empty for RSC requests.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 15, 2026

Review posted successfully. Here's a summary of the feedback:

Overall: Solid PR. The canonical-bytes invariant is well-maintained, the defense-in-depth security model via computeSkipDecision is the right approach, and the supportsFilteredRscStream: false gate ensures this lands inert.

Key observations posted as inline comments:

  1. addRefsFromRaw dual-pass — The regex scan and JSON parse both extract references from the same payload. Safe due to Set dedup, but double work on JSON-carrying rows. The regex is needed for non-JSON rows like 1:D"$3a", so the two can't collapse.

  2. REFERENCE_PATTERN tag list — Suggested adding a link to the specific React Flight source (ReactFlightClient.js) to help future maintainers audit when upgrading React.

  3. promoteToStreaming hardcodes 0: — Suggested preserving the original prefix from row0Raw instead, as a minor defensive measure.

  4. parseSkipHeader unbounded allocation — A malicious client could send many entries. CF Workers enforce header size limits, but an application-level cap (e.g., 50 entries) would add defense-in-depth.

  5. new Set() on every non-RSC request — Minor allocation that could use a shared sentinel for consistency with EMPTY_SKIP_SET patterns elsewhere.

  6. Positive note on cache hot pathEMPTY_SKIP_SET fallback ensures the cache-hit path stays allocation-free when no skip header is present.

  7. HTML SSR path clarityrscForResponse is also used for SSR HTML rendering, but skip filter is correctly gated by isRscRequest. Suggested a clarifying comment at the usage site.

github run

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: feat(app-router): filter skipped layouts from RSC responses and cached reads [3/6]

Strong PR. The architecture is sound — the canonical-bytes invariant (cache always sees full bytes, filter only on egress) is clearly maintained, the defense-in-depth via computeSkipDecision is the right approach, and the supportsFilteredRscStream: false gate ensures this lands inert. Test coverage is thorough across unit, cache, and render integration paths.

Two real issues below; the rest are minor observations.

decision.add(id);
}
}
return decision;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

computeSkipDecision allocates a new Set<string>() even when no requested ids match any "s" flags (i.e., the client asked to skip layouts but the server classified all of them as dynamic). The function returns this empty Set instead of EMPTY_SKIP_DECISION.

This means skipIds.size > 0 downstream is still false so it's not a correctness bug, but it defeats the purpose of the EMPTY_SKIP_DECISION sentinel since the caller (renderAppPageLifecycle) checks skipIds.size > 0 to decide whether to create a transform stream. Consider:

Suggested change
return decision;
return decision.size > 0 ? decision : EMPTY_SKIP_DECISION;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. computeSkipDecision now returns the shared empty sentinel when every requested id is rejected, and there’s a focused test that checks the all-dynamic case reuses the same empty reference instead of leaking a fresh Set.

}

const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component");
const __EMPTY_SKIP_LAYOUT_IDS = new Set();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous review flagged that this allocates a fresh new Set() on every non-RSC request. The author responded that this was fixed to use a shared empty sentinel, but the code still shows const __EMPTY_SKIP_LAYOUT_IDS = new Set() inside the request handler body — meaning it's allocated per-request, not shared.

To actually share it, this needs to be hoisted outside _handleRequest to module scope in the generated entry (similar to how EMPTY_SKIP_SET is a module-level constant in app-page-render.ts and app-page-cache.ts). The current placement inside the function body means a new Set instance is created on every request, including non-RSC ones where __skipLayoutIds immediately points to it.

Not a correctness issue (empty Set is cheap), but the intent from the review response doesn't match what landed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The empty skip set is now hoisted to module scope in the generated RSC entry template, so non-RSC requests really reuse a shared sentinel instead of allocating inside _handleRequest. Updated the entry snapshots with the hoist.

// object/array payloads, for example `1:D"$3a"`. Track those too so
// later rows are not dropped as orphans when a kept row introduces a
// new live id through a deferred chunk.
for (const match of payload.matchAll(/(?<!\$)\$(?:[LBFQWK@])?([0-9a-fA-F]+)\b/g)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the inline regex here duplicates the tag character class from REFERENCE_PATTERN on line 32. If a new React Flight tag is added and only one pattern is updated, the filter would silently diverge between the JSON walk path and the raw-regex path.

Consider extracting the tag class to a shared constant, e.g.:

const FLIGHT_REF_TAGS = "LBFQWK@";
const REFERENCE_PATTERN = new RegExp(`^\\$(?:[${FLIGHT_REF_TAGS}])?([0-9a-fA-F]+)$`);

and reusing FLIGHT_REF_TAGS in the matchAll regex on this line. Not blocking, but reduces the audit surface on React upgrades.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied. The Flight reference tags now live in a shared FLIGHT_REF_TAGS constant, and both REFERENCE_PATTERN and the raw matchAll regex derive from it. That keeps the JSON-walk path and raw-row path aligned for future React upgrades.

state.pending.push({ kind: "passthrough", raw });
continue;
}
if (id === 0) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth noting: when row 0 arrives, promoteToStreaming may replace state from initial to streaming (or passthrough on JSON parse failure). The consumeBuffered loop continues iterating the same buffer and the next iteration falls through to the streaming/passthrough branch, which is correct. But if promoteToStreaming enters the JSON-parse-failure fallback (state = { phase: "passthrough" }), the carry from the initial phase is already empty (line 255 clears state.pending), yet the new passthrough state's carry starts at "". This is fine because consumeBuffered sets state.carry at the end of the loop, but it took me a few reads to verify. The docblock on line 223 helps — just flagging this as the most subtle control flow in the file.

const isrRscDataPromise = rscCapture.capturedRscDataPromise;

const skipIds =
options.isRscRequest && (options.supportsFilteredRscStream ?? true)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ?? true default for supportsFilteredRscStream means the filter is opt-out rather than opt-in. Since the generated entry currently always passes false, this is safe today. But if a future caller forgets to set the flag, the filter will activate by default.

Given that this is explicitly "dormant until canonical-stream is validated" per the PR description, an opt-in default (?? false) would be more conservative and match the stated intent. When PR 5/6 or 6/6 is ready to activate the filter, it would explicitly set true.

Suggested change
options.isRscRequest && (options.supportsFilteredRscStream ?? true)
options.isRscRequest && (options.supportsFilteredRscStream ?? false)

This way, omitting the flag keeps the filter off rather than on.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied. The dormant filter is now opt-in: supportsFilteredRscStream defaults to false in renderAppPageLifecycle, and the render tests now prove that omitting the flag keeps the response canonical until a later PR explicitly enables it.

}

return new Response(cachedValue.rscData, {
const body = wrapRscBytesForResponse(cachedValue.rscData, options.skipIds ?? EMPTY_SKIP_SET);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cache-read path applies skipIds to the cached canonical bytes here. This is correct for the response sent to the current client, but I want to confirm: the skipIds used here come from the current request's X-Vinext-Router-Skip header (threaded from the generated entry → readAppPageCacheResponsebuildAppPageCachedResponse), not from the request that originally populated the cache. This is the right behavior — each client gets a response filtered to its own skip set — just want to make sure this is documented as intentional since it could be confusing during debugging (same cache entry, different response bytes for different clients).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented. Added an inline comment on the cache-read path clarifying that the cached bytes stay canonical and the current request’s skipIds only shape the egress response for this client. That matches the intended canonical-cache / per-client-response split.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 16, 2026

Review posted successfully. Here's a summary of what I flagged:

Two real issues:

  1. computeSkipDecision leaks an empty Set allocation (app-elements.ts:147) — when all requested skip ids are classified as dynamic, the function returns a freshly allocated empty Set<string> instead of the shared EMPTY_SKIP_DECISION sentinel. Not a correctness bug but defeats the purpose of the sentinel pattern. Suggested adding return decision.size > 0 ? decision : EMPTY_SKIP_DECISION.

  2. __EMPTY_SKIP_LAYOUT_IDS is per-request, not shared (app-rsc-entry.ts:1425) — The previous review flagged fresh new Set() allocation on every non-RSC request. The author responded it was fixed, but the const __EMPTY_SKIP_LAYOUT_IDS = new Set() is still inside the _handleRequest function body, so it allocates per-request. It should be hoisted to module scope to actually share it.

Three observations/suggestions:

  1. Duplicated Flight tag character class (app-page-skip-filter.ts:135) — The inline regex in addRefsFromRaw duplicates the [LBFQWK@] tag class from REFERENCE_PATTERN. Suggested extracting to a shared constant to reduce the audit surface on React upgrades.

  2. supportsFilteredRscStream defaults to true (app-page-render.ts:187) — The ?? true default means the filter is opt-out. Since the PR is explicitly dormant, an opt-in default (?? false) would be more conservative and match the stated intent.

  3. Cache-read skip filtering is per-client (app-page-cache.ts:114) — Confirmed that skipIds comes from the current request, not from the request that populated the cache. This is correct but noted for documentation clarity since the same cache entry produces different response bytes for different clients.

github run

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: feat(app-router): filter skipped layouts from RSC responses and cached reads [3/6]

All feedback from the two prior review rounds has been addressed in the current code:

  • computeSkipDecision returns EMPTY_SKIP_DECISION when all requested ids are rejected (line 147)
  • __EMPTY_SKIP_LAYOUT_IDS is hoisted to module scope in the generated entry (line 497)
  • FLIGHT_REF_TAGS is extracted as a shared constant driving both REFERENCE_PATTERN and RAW_REFERENCE_PATTERN
  • supportsFilteredRscStream defaults to false (opt-in), matching the stated dormancy intent
  • parseSkipHeader caps at 50 entries
  • Clarifying comments added at the HTML SSR call site and cache-read path

The architecture is sound. The canonical-bytes invariant is cleanly maintained: the tee sends full bytes to the cache branch, and createSkipFilterTransform only applies to the egress/response branch. The defense-in-depth via computeSkipDecision (server must independently classify a layout as static) is the right security model.

Two minor nits below — neither blocking.

}
});
});
describe("skip header filtering", () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: missing blank line between the closing }); of the previous describe block and this new describe. Every other top-level describe in this file has a blank separator.

Suggested change
describe("skip header filtering", () => {
describe("skip header filtering", () => {

addRefsFromRaw(raw, state.liveIds);
}
}
state.carry = "";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: when consumeBuffered is called from flush with buffer + "\n", and row 0 arrives as the final line, promoteToStreaming replaces state and the loop continues to the next iteration. At that point cursor === buffer.length, so the while-loop exits and we fall through to state.carry = "". This clears the new streaming state's carry, which is correct but only because the synthesized newline means there's never trailing content after the last row.

This is fine — just noting it as the subtlest control flow in the file. The docblock on line 228 helps.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 16, 2026

Review posted. Here's a summary of what I found:

Verdict: Approved

All feedback from the two prior review rounds has been properly addressed in the current code:

  1. computeSkipDecision empty set sentinel — returns EMPTY_SKIP_DECISION when all requested ids are rejected (was leaking a fresh Set)
  2. __EMPTY_SKIP_LAYOUT_IDS hoisting — now at module scope in the generated entry template, no longer per-request
  3. FLIGHT_REF_TAGS shared constant — both REFERENCE_PATTERN and RAW_REFERENCE_PATTERN derive from it
  4. supportsFilteredRscStream opt-in default — defaults to false, matching the dormancy intent
  5. parseSkipHeader bounded — caps at 50 entries
  6. Clarifying comments — added at HTML SSR call site and cache-read path

The canonical-bytes invariant is cleanly maintained, the defense-in-depth security model via computeSkipDecision is correct, and the test coverage (44 filter tests + cache + render integration) is thorough. Two minor formatting nits posted inline — neither blocking.

github run

Comment on lines +27 to +32
// React Flight row-reference tags come from react-server-dom-webpack's client
// parser (`resolveModel` in ReactFlightClient.js). Audit this character class
// when upgrading React so new reference-like tags do not become silent drop
// cases in the filter:
// https://github.com/facebook/react/blob/main/packages/react-client/src/ReactFlightClient.js
const FLIGHT_REF_TAGS = "LBFQWK@";
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels rather fragile and could be rather problematic if people want to use their own versions, so I don't really think this is an approach we should rely on...

Is there another way we can achieve what we're trying to do without that?

I don't think any of the reference frameworks hack around the wire format response - I believe they would filter before it gets to this point. Given that people can bring their own versions of React instead of vendoring it, this would be very risky.

// object/array payloads, for example `1:D"$3a"`. Track those too so
// later rows are not dropped as orphans when a kept row introduces a
// new live id through a deferred chunk.
for (const match of payload.matchAll(RAW_REFERENCE_PATTERN)) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't it be possible for this to potentially throw up false positives from scanning the payload?

Comment on lines +190 to +193
const rscForResponse =
skipIds.size > 0
? rscCapture.responseStream.pipeThrough(createSkipFilterTransform(skipIds))
: rscCapture.responseStream;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So from what I gather, it sounds like we render the entire tree, then skip returning parts of that tree to client? Wouldn't we want to skip rendering those before-hand to reduce server work? I believe both Next.js and Waku would skip before the rendering.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants